<?php
/*
Copyright (c) 2008 Gradua Networks
Author: Bruno Prieto Reis

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

Usage:

//this optional check mode may be set so that strings containing doubles or integers are
//validated ok against a schema defining an integer or a double.
//JsonSchema::$checkMode = JsonSchema::CHECK_MODE_TYPE_CAST;

$result = JsonSchema::validate(
  $json,
  $schema
);
* */

class JsonSchema {

  const TYPE_ERROR = 'TYPE';
  const ADDITIONAL_PROPERTIES_ERROR = 'ADDITIONAL_PROPERTIES';
  /**
   * Indicates that the value is required.
   */
  const OPTIONAL_ERROR = 'OPTIONAL';
  /**
   * Indicates that this property requires another one to be present.
   */
  const REQUIRES_ERROR = 'REQUIRES';
  const MINIMUM_ERROR = 'MINIMUM';
  const MAXIMUM_ERROR = 'MAXIMUM';
  const MIN_ITEMS_ERROR = 'MIN_ITEMS';
  const MAX_ITEMS_ERROR = 'MAX_ITEMS';
  const PATTERN_ERROR = 'PATTERN_ERROR';
  const MAX_LENGTH_ERROR = 'MAX_LENGTH';
  const MIN_LENGTH_ERROR = 'MIN_LENGTH';
  const ENUM_ERROR = 'ENUM';
  const READONLY_ERROR = 'READONLY';
  const FORMAT_ERROR = 'FORMAT';
  const MAX_DECIMAL_ERROR = 'MAX_DECIMAL';
  const DISALLOW_ERROR = 'DISALLOW';

  static $errors = array();
  static $formatValidator;

  const CHECK_MODE_NORMAL = 1;
  const CHECK_MODE_TYPE_CAST = 2;
  public static $checkMode = self::CHECK_MODE_NORMAL;

  /**
   * Validates a php object against a schema. Both the php object and the schema
   * are supposed to be a result of a json_decode call.
   * The validation works as defined by the schema proposal in
   * http://www.json.com/json-schema-proposal/
   *
   * @param StdClass $instance
   * @param StdClass $schema
   * @param JsonFormatValidator $formatValidator an optional class that have methods to validate the format definitions.
   * If this is null, so format validation will not be applied, but if its true, then the validation will throw
   * an error if any format defined on the schema is not supported by the validator.
   * @return unknown
   */
  static public function validate($instance, $schema = null, $formatValidator = null) {
    self::$errors = array();
    self::$formatValidator = null;

    if($formatValidator) self::$formatValidator = $formatValidator;
    $res = self::_validate($instance,$schema,false);
    self::$formatValidator = null;
    return $res;
  }

  static function _validate($instance,$schema = null,$_changing) {
  	// verify passed schema
    if ($schema) {
	  self::checkProp($instance,$schema,'','',$_changing);
    }
    // verify "inline" schema
    $propName = '$schema';
	if (!$_changing && isset($instance->$propName)) {
	  self::checkProp($instance,$instance->$propName,'','',$_changing);
	}
	// show results
	$obj = new stdClass();
	$obj->valid = ! ((boolean)count(self::$errors));
	$obj->errors = self::$errors;
	return $obj;
  }

  static function incrementPath($path,$i) {
    if($path !== '') {
	  if(is_int($i)) {
	    $path .= '['.$i.']';
	  }
	  elseif($i == '') {
	    $path .= '';
	  }
	  else {
	    $path .= '.'.$i;
	  }
    }
    else {
      $path = $i;
    }
    return $path;
  }

  static function checkArray($value,$schema,$path,$i,$_changing) {
    //verify items
    if(isset($schema->items)) {
      //tuple typing
      if(is_array($schema->items)) {
        foreach($value as $k=>$v) {
          if(array_key_exists($k,$schema->items)) {
            self::checkProp($v,$schema->items[$k],$path,$k,$_changing);
          }
          else {
            // aditional array properties
            if(array_key_exists('additionalProperties',$schema)) {
              if($schema->additionalProperties === false) {
                self::adderror(
                  $path,
                  'The item '.$i.'['.$k.'] is not defined in the objTypeDef and the objTypeDef does not allow additional properties',
                  self::ADDITIONAL_PROPERTIES_ERROR
                );
              }
              else {
                self::checkProp($v,$schema->additionalProperties,$path,$k,$_changing);
              }
            }
          }
        }//foreach($value as $k=>$v) {
        // treat when we have more schema definitions than values
        for($k = count($value); $k < count($schema->items); $k++) {
          self::checkProp(
            new JsonSchemaUndefined(),
            $schema->items[$k], $path, $k, $_changing
          );
        }
      }
      // just one type definition for the whole array
      else {
        foreach($value as $k=>$v) {
          self::checkProp($v,$schema->items,$path,$k,$_changing);
        }
      }
    }
    // verify number of array items
    if(isset($schema->minItems) && count($value) < $schema->minItems) {
      self::adderror($path,"There must be a minimum of " . $schema->minItems . " in the array",self::MIN_ITEMS_ERROR);
    }
    if(isset($schema->maxItems) && count($value) > $schema->maxItems) {
      self::adderror($path,"There must be a maximum of " . $schema->maxItems . " in the array",self::MAX_ITEMS_ERROR);
    }
  }

  static function checkProp($value, $schema, $path, $i = '', $_changing = false) {
    if (!is_object($schema)) {
    	return;
    }
    $path = self::incrementPath($path,$i);
    // verify readonly
    if($_changing && $schema.readonly) {
      self::adderror($path,'is a readonly field, it can not be changed',self::READONLY_ERROR);
    }
    // if it extends another schema, it must pass that schema as well
    if(isset($schema->extends)) {
      self::checkProp($value,$schema->extends,$path,$i,$_changing);
    }
    // verify optional values
    if (is_object($value) && $value instanceOf JsonSchemaUndefined) {
  	  if ( isset($schema->optional) ? !$schema->optional : true) {
	    self::adderror($path, "is missing and it is not optional", self::OPTIONAL_ERROR);
	  }
    }
    // normal verifications
    else {
      self::$errors = array_merge(
        self::$errors,
        self::checkType( isset($schema->type) ? $schema->type : null , $value, $path)
      );
    }
    if(array_key_exists('disallow',$schema)) {
      $errorsBeforeDisallowCheck = self::$errors;
      $response = self::checkType($schema->disallow, $value, $path);
      if(
        ( count($errorsBeforeDisallowCheck) == count(self::$errors) )  &&
        !count($response)
      ) {
        self::adderror($path," disallowed value was matched", self::DISALLOW_ERROR);
      }
      else {
        self::$errors = $errorsBeforeDisallowCheck;
      }
    }
    //verify the itens on an array and min and max number of items.
    if(is_array($value)) {
      if(
        self::$checkMode == self::CHECK_MODE_TYPE_CAST &&
        $schema->type == 'object'
      ) {
        self::checkObj(
          $value,
          $schema->properties,
          $path,
          isset($schema->additionalProperties) ? $schema->additionalProperties : null,
          $_changing
        );
      }
      self::checkArray($value,$schema,$path,$i,$_changing);
    }
    ############ verificar!
    elseif(isset($schema->properties) && is_object($value)) {
      self::checkObj(
        $value,
        $schema->properties,
        $path,
        isset($schema->additionalProperties) ? $schema->additionalProperties : null,
        $_changing
      );
    }
    // verify a regex pattern
    if( isset($schema->pattern) && is_string($value) && !preg_match('/'.$schema->pattern.'/',$value)) {
      self::adderror($path,"does not match the regex pattern " . $schema->pattern,self::PATTERN_ERROR);
    }
    // verify maxLength, minLength, maximum and minimum values
    if( isset($schema->maxLength) && is_string($value) && (iconv_strlen($value, 'UTF-8') > $schema->maxLength)) {
      self::adderror($path,"must be at most " . $schema->maxLength . " characters long", self::MAX_LENGTH_ERROR);
    }
    if( isset($schema->minLength) && is_string($value) && iconv_strlen($value, 'UTF-8') < $schema->minLength) {
      self::adderror($path,"must be at least " . $schema->minLength . " characters long", self::MIN_LENGTH_ERROR);
    }

    if(
      isset($schema->minimum) &&
      gettype($value) == gettype($schema->minimum) &&
      $value < $schema->minimum
    ) {
      self::adderror($path,"must have a minimum value of " . $schema->minimum, self::MINIMUM_ERROR);
    }
    if( isset($schema->maximum) && gettype($value) == gettype($schema->maximum) && $value > $schema->maximum) {
      self::adderror($path,"must have a maximum value of " . $schema->maximum, self::MAXIMUM_ERROR);
    }
    // verify enum values
    if(isset($schema->enum) && !$value instanceof JsonSchemaUndefined) {
      $found = false;
      foreach($schema->enum as $possibleValue) {
        if($possibleValue == $value) {
          $found = true;
          break;
        }
      }
      if(!$found) {
        self::adderror($path,"does not have a value in the enumeration " . implode(', ',$schema->enum), self::ENUM_ERROR);
      }
    }
    if(
      isset($schema->maxDecimal) &&
      (  ($value * pow(10,$schema->maxDecimal)) != (int)($value * pow(10,$schema->maxDecimal))  )
    ) {
      self::adderror($path,"may only have " . $schema->maxDecimal . " digits of decimal places", self::MAX_DECIMAL_ERROR);
    }
    if( isset($schema->format) && isset(self::$formatValidator) && $value ) {
      $error = self::$formatValidator->validate($value,$schema->format);
      if($error) {
        self::adderror($path,$error,self::FORMAT_ERROR);
      }
    }
  }

  static function adderror($path,$message,$type = null) {
  	self::$errors[] = array(
      'property'=>$path,
      'message'=>$message,
  	  'type'=>$type
    );
  }

  /**
   * Take Care: Value is being passed by ref to continue validation with proper format.
   * @return array
   */
  static function checkType($type, &$value, $path) {
    if($type) {
      $wrongType = false;
      if(is_string($type) && $type !== 'any') {

        if($type == 'null' || $type == 'NULL') {
          if (!is_null($value)) {
            $wrongType = true;
          }
        }
        else {
          if($type == 'number') {
            if(self::$checkMode == self::CHECK_MODE_TYPE_CAST) {
              $wrongType = !self::checkTypeCast($type,$value);
            }
          	elseif(!in_array(gettype($value),array('integer','double'))) {
          	  $wrongType = true;
          	}
          } else{
            if(
              self::$checkMode == self::CHECK_MODE_TYPE_CAST
              && $type == 'integer'
            ) {
              $wrongType = !in_array(gettype($value),array('string','integer')) || !self::checkTypeCast($type,$value);
            } elseif (
              self::$checkMode == self::CHECK_MODE_TYPE_CAST
              && $type == 'object' && is_array($value)
            ) {
              $wrongType = false;
            } elseif ($type !== gettype($value)) {
          	  $wrongType = true;
            }
          }
        }
      }
      if($wrongType) {
      	return array(
      	  array(
      	    'property'=>$path,
      	    'message'=>gettype($value)." value found, but a ".$type." is required ",
      	    'type'=>self::TYPE_ERROR
      	  )
      	);
      }
      // Union Types  :: for now, just return the message for the last expected type!!
      if(is_array($type)) {
        $validatedOneType = false;
        $errors = array();
        foreach($type as $tp) {
          $error = self::checkType($tp,$value,$path);
          if(!count($error)) {
          	$validatedOneType = true;
          	break;
          }
          else {
          	$errors[] = $error;
          	$errors = $error;
          }
        }
        if(!$validatedOneType) {
          return $errors;
        }
      }
      elseif(is_object($type)) {
      	self::checkProp($value,$type,$path);
      }
    }
    return array();
  }

  /**
   * Take Care: Value is being passed by ref to continue validation with proper format.
   */
  static function checkTypeCast($type,&$value) {
    switch($type) {
  	  case 'integer':
  	    $castValue = (integer)$value;
  	    break;
  	  case 'number':
  		$castValue = (double)$value;
  		break;
  	  default:
  	    trigger_error('this method should only be called for the above supported types.');
  	    break;
  	}
  	if( (string)$value == (string)$castValue ) {
  	  $res = true;
  	  $value = $castValue;
  	}
  	else {
  	  $res = false;
  	}
  	return $res;
  }

  static function checkObj($instance, $objTypeDef, $path, $additionalProp,$_changing) {
    if($objTypeDef instanceOf StdClass) {
      if( ! (($instance instanceOf StdClass) || is_array($instance)) ) {
      	self::$errors[] = array(
      	  'property'=>$path,
      	  'message'=>"an object is required",
      	  self::TYPE_ERROR
      	);
      }
      foreach($objTypeDef as $i=>$value) {
        $value =
          array_key_exists($i,$instance) ?
            (is_array($instance) ? $instance[$i] : $instance->$i) :
            new JsonSchemaUndefined();
        $propDef = $objTypeDef->$i;
        self::checkProp($value,$propDef,$path,$i,$_changing);
      }
    }
    // additional properties and requires
    foreach($instance as $i=>$value) {
      // verify additional properties, when its not allowed
      if( !isset($objTypeDef->$i) && ($additionalProp === false) && $i !== '$schema' ) {
      	self::$errors[] = array(
      	  'property'=>$path,
      	  'message'=>"The property " . $i . " is not defined in the objTypeDef and the objTypeDef does not allow additional properties",
      	  self::ADDITIONAL_PROPERTIES_ERROR
      	);
      }
      // verify requires
      if($objTypeDef && isset($objTypeDef->$i) && isset($objTypeDef->$i->requires)) {
        $requires = $objTypeDef->$i->requires;
        if(!array_key_exists($requires,$instance)) {
          self::$errors[] = array(
      	    'property'=>$path,
      	    'message'=>"the presence of the property " . $i . " requires that " . $requires . " also be present",
            self::REQUIRES_ERROR
      	  );
        }
      }
	  $value = is_array($instance) ? $instance[$i] : $instance->$i;

	  // To verify additional properties types.
	  if ($objTypeDef && is_object($objTypeDef) && !isset($objTypeDef->$i)) {
	    self::checkProp($value,$additionalProp,$path,$i);
      }
      // Verify inner schema definitions
	  $schemaPropName = '$schema';
	  if (!$_changing && $value && isset($value->$schemaPropName)) {
        self::$errors = array_merge(
          self::$errors,
          checkProp($value,$value->$schemaPropname,$path,$i)
        );
	  }
    }
	return self::$errors;
  }
}


?>